Skip to main content

Complete ARIA & Keyboard Navigation

A comprehensive guide to ARIA roles, states, properties, and keyboard navigation patterns for building accessible web applications.

Table of Contentsโ€‹

  1. ARIA Roles
  2. ARIA States & Properties
  3. Accessible Notes & Text Areas
  4. Keyboard Navigation Patterns
  5. Live Regions & Dynamic Content
  6. Form Accessibility
  7. Modal & Dialog Patterns
  8. Testing & Validation

ARIA Rolesโ€‹

ARIA roles define what an element is semantically to assistive technologies. They communicate the purpose and behavior of elements.

Landmark Rolesโ€‹

Landmark roles help users navigate page structure and find content quickly.

<!-- Site-wide header -->
<header role="banner">
<h1>My Website</h1>
<nav role="navigation" aria-label="Main menu">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>

<!-- Main content area -->
<main role="main">
<article>
<h2>Article Title</h2>
<p>Article content...</p>
</article>
</main>

<!-- Sidebar content -->
<aside role="complementary" aria-labelledby="sidebar-heading">
<h3 id="sidebar-heading">Related Links</h3>
<ul>
<li><a href="#">Link 1</a></li>
<li><a href="#">Link 2</a></li>
</ul>
</aside>

<!-- Site footer -->
<footer role="contentinfo">
<p>&copy; 2025 My Website</p>
</footer>

๐Ÿ’ก Best Practice: Use semantic HTML elements (<header>, <nav>, <main>, <aside>, <footer>) which have implicit ARIA roles. Only add explicit roles when semantic HTML isn't sufficient.

Widget Rolesโ€‹

Widget roles define interactive components and their expected behaviors.

<!-- Custom button -->
<div
role="button"
tabindex="0"
aria-pressed="false"
onkeydown="handleButtonKeydown(event)"
onclick="toggleButton()"
>
Toggle Setting
</div>

<!-- Custom checkbox -->
<div
role="checkbox"
tabindex="0"
aria-checked="false"
aria-labelledby="custom-checkbox-label"
>
<span id="custom-checkbox-label">Enable notifications</span>
</div>

<!-- Dialog/Modal -->
<div
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
aria-modal="true"
>
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-description">Are you sure you want to delete this item?</p>
<button>Cancel</button>
<button>Delete</button>
</div>

<!-- Tab interface -->
<div role="tablist" aria-label="Settings tabs">
<button
role="tab"
aria-selected="true"
aria-controls="general-panel"
id="general-tab"
>
General
</button>
<button
role="tab"
aria-selected="false"
aria-controls="privacy-panel"
id="privacy-tab"
>
Privacy
</button>
</div>

<div role="tabpanel" id="general-panel" aria-labelledby="general-tab">
<h3>General Settings</h3>
<!-- Panel content -->
</div>

Document Structure Rolesโ€‹

<!-- Article with proper heading structure -->
<article role="article">
<header>
<h1>Article Title</h1>
<p>Published on <time datetime="2025-01-15">January 15, 2025</time></p>
</header>

<div role="region" aria-labelledby="section1">
<h2 id="section1">Introduction</h2>
<p>Content...</p>
</div>
</article>

<!-- Data table -->
<table role="table" aria-label="Sales data">
<thead>
<tr role="row">
<th role="columnheader">Month</th>
<th role="columnheader">Sales</th>
<th role="columnheader">Growth</th>
</tr>
</thead>
<tbody>
<tr role="row">
<td role="cell">January</td>
<td role="cell">$10,000</td>
<td role="cell">+5%</td>
</tr>
</tbody>
</table>

<!-- List with custom styling -->
<ul role="list" aria-label="Feature list">
<li role="listitem">Feature 1</li>
<li role="listitem">Feature 2</li>
<li role="listitem">Feature 3</li>
</ul>

ARIA States & Propertiesโ€‹

ARIA states and properties describe the current condition and relationships of elements.

Common Statesโ€‹

States describe the current condition of an element and can change frequently.

<!-- Disabled state -->
<button aria-disabled="true" onclick="handleClick(event)">
Save (processing...)
</button>

<!-- Expanded/Collapsed states -->
<button aria-expanded="false" aria-controls="menu-items" onclick="toggleMenu()">
Menu <span aria-hidden="true">โ–ผ</span>
</button>
<ul id="menu-items" hidden>
<li><a href="#">Item 1</a></li>
<li><a href="#">Item 2</a></li>
</ul>

<!-- Checked states -->
<div
role="checkbox"
aria-checked="false"
tabindex="0"
onclick="toggleCheck(this)"
>
<span class="checkbox-icon" aria-hidden="true">โ˜</span>
Enable feature
</div>

<div role="checkbox" aria-checked="mixed" tabindex="0">
<span class="checkbox-icon" aria-hidden="true">โ˜‘</span>
Select all items (some selected)
</div>

<!-- Selected state -->
<ul role="listbox" aria-label="Color options">
<li role="option" aria-selected="false" tabindex="0">Red</li>
<li role="option" aria-selected="true" tabindex="-1">Blue</li>
<li role="option" aria-selected="false" tabindex="-1">Green</li>
</ul>

<!-- Hidden state -->
<div aria-hidden="true" class="decorative-icon">๐ŸŽ‰</div>
<span class="sr-only">Celebration complete!</span>

Properties (Relationships & Descriptions)โ€‹

Properties describe relationships between elements and provide additional context.

<!-- Labeling -->
<input
type="email"
id="email-input"
aria-label="Email address"
aria-required="true"
aria-invalid="false"
/>

<!-- Label by reference -->
<h2 id="billing-heading">Billing Information</h2>
<fieldset aria-labelledby="billing-heading">
<input type="text" placeholder="Card number" />
<input type="text" placeholder="Expiry date" />
</fieldset>

<!-- Described by reference -->
<input
type="password"
id="password"
aria-describedby="password-help password-strength"
/>
<div id="password-help">Password must be at least 8 characters long</div>
<div id="password-strength" aria-live="polite">Password strength: Weak</div>

<!-- Controls relationship -->
<button
aria-controls="video-player"
aria-pressed="false"
onclick="togglePlayback()"
>
<span aria-hidden="true">โ–ถ๏ธ</span>
Play video
</button>
<video id="video-player" src="video.mp4"></video>

<!-- Owns relationship -->
<div role="combobox" aria-owns="suggestions-list" aria-expanded="false">
<input type="text" aria-autocomplete="list" />
<ul id="suggestions-list" role="listbox" hidden>
<li role="option">Suggestion 1</li>
<li role="option">Suggestion 2</li>
</ul>
</div>

<!-- Flow to (reading order) -->
<div id="step1">
<h3>Step 1: Enter details</h3>
<input type="text" aria-flowto="step2" />
</div>
<div id="step2" aria-flowto="step3">
<h3>Step 2: Review</h3>
<!-- content -->
</div>

Accessible Notes & Text Areasโ€‹

Creating accessible text input areas for notes, comments, and long-form content.

Basic Textarea Implementationโ€‹

<!-- Semantic HTML approach (preferred) -->
<div class="form-group">
<label for="notes">Meeting Notes</label>
<textarea
id="notes"
name="notes"
rows="6"
cols="50"
aria-required="true"
aria-describedby="notes-help notes-count"
placeholder="Enter your notes here..."
></textarea>

<div id="notes-help" class="help-text">
Include key discussion points and action items
</div>

<div id="notes-count" class="character-count" aria-live="polite">
0 / 500 characters
</div>
</div>

Rich Text Editor (Contenteditable)โ€‹

When you need more than basic textarea functionality:

<div class="rich-editor">
<label id="editor-label">Article Content</label>

<!-- Toolbar -->
<div
role="toolbar"
aria-label="Formatting options"
aria-controls="editor-content"
>
<button
type="button"
aria-pressed="false"
onclick="toggleFormat('bold')"
title="Bold (Ctrl+B)"
>
<strong aria-hidden="true">B</strong>
<span class="sr-only">Bold</span>
</button>

<button
type="button"
aria-pressed="false"
onclick="toggleFormat('italic')"
title="Italic (Ctrl+I)"
>
<em aria-hidden="true">I</em>
<span class="sr-only">Italic</span>
</button>
</div>

<!-- Editor content -->
<div
id="editor-content"
role="textbox"
aria-multiline="true"
aria-labelledby="editor-label"
aria-describedby="editor-help"
contenteditable="true"
spellcheck="true"
tabindex="0"
onkeydown="handleEditorKeydown(event)"
oninput="updateCharCount()"
></div>

<div id="editor-help" class="help-text">
Use Ctrl+B for bold, Ctrl+I for italic. Press Shift+F10 for formatting
options.
</div>
</div>

Error States and Validationโ€‹

<div class="form-group">
<label for="required-notes">Project Description *</label>

<textarea
id="required-notes"
aria-required="true"
aria-invalid="true"
aria-describedby="notes-error notes-help"
>
</textarea>

<!-- Error message -->
<div id="notes-error" role="alert" class="error-message" aria-atomic="true">
<span class="error-icon" aria-hidden="true">โš ๏ธ</span>
Project description is required and must be at least 10 characters long.
</div>

<!-- Help text -->
<div id="notes-help" class="help-text">
Describe the project goals, timeline, and key deliverables.
</div>
</div>

Advanced Notes Featuresโ€‹

<div class="advanced-notes-editor">
<!-- Header with metadata -->
<div class="notes-header">
<h3 id="notes-title">Session Notes</h3>
<div class="notes-meta" aria-label="Note metadata">
<span>Last saved: <time id="last-saved">2 minutes ago</time></span>
<span>Word count: <span id="word-count">247</span></span>
</div>
</div>

<!-- Main editor -->
<div class="editor-container">
<textarea
id="advanced-notes"
aria-labelledby="notes-title"
aria-describedby="editor-status keyboard-shortcuts"
spellcheck="true"
autocorrect="on"
autocapitalize="sentences"
onkeydown="handleAdvancedKeydown(event)"
oninput="autoSave()"
onfocus="showKeyboardHelp()"
onblur="hideKeyboardHelp()"
>
</textarea>

<!-- Auto-save status -->
<div
id="editor-status"
aria-live="polite"
aria-atomic="false"
class="save-status"
>
All changes saved
</div>
</div>

<!-- Keyboard shortcuts help -->
<div
id="keyboard-shortcuts"
class="keyboard-help"
role="region"
aria-label="Keyboard shortcuts"
>
<h4>Keyboard Shortcuts</h4>
<dl>
<dt>Ctrl + S</dt>
<dd>Save notes</dd>
<dt>Ctrl + Z</dt>
<dd>Undo</dd>
<dt>Ctrl + Y</dt>
<dd>Redo</dd>
<dt>Ctrl + F</dt>
<dd>Find in notes</dd>
</dl>
</div>
</div>

Keyboard Navigation Patternsโ€‹

Comprehensive keyboard navigation patterns for different UI components.

Focus Management Principlesโ€‹

// Focus management utilities
class FocusManager {
constructor() {
this.focusableSelectors = [
'a[href]',
'button',
'input',
'select',
'textarea',
'[tabindex]',
'[contenteditable="true"]',
].join(', ');
}

getFocusableElements(container) {
const elements = container.querySelectorAll(this.focusableSelectors);
return Array.from(elements).filter(el => {
return (
!el.disabled && !el.hasAttribute('aria-hidden') && el.tabIndex !== -1
);
});
}

trapFocus(container) {
const focusableElements = this.getFocusableElements(container);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

container.addEventListener('keydown', e => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
});
}
}

Button Navigationโ€‹

<div class="button-group" role="group" aria-label="Document actions">
<button
type="button"
onclick="saveDocument()"
onkeydown="handleButtonKeydown(event, 'save')"
>
Save
</button>

<button
type="button"
onclick="previewDocument()"
onkeydown="handleButtonKeydown(event, 'preview')"
>
Preview
</button>

<button
type="button"
onclick="publishDocument()"
onkeydown="handleButtonKeydown(event, 'publish')"
>
Publish
</button>
</div>

<script>
function handleButtonKeydown(event, action) {
// Enter and Space activate buttons
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.target.click();
}

// Arrow key navigation within button group
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const buttons = [...event.target.parentNode.querySelectorAll('button')];
const currentIndex = buttons.indexOf(event.target);
let nextIndex;

if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % buttons.length;
} else {
nextIndex = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1;
}

buttons[nextIndex].focus();
event.preventDefault();
}
}
</script>
<div class="menu-container">
<button
id="menu-trigger"
aria-haspopup="true"
aria-expanded="false"
aria-controls="main-menu"
onkeydown="handleMenuTriggerKeydown(event)"
onclick="toggleMenu()"
>
File <span aria-hidden="true">โ–ผ</span>
</button>

<ul
id="main-menu"
role="menu"
aria-labelledby="menu-trigger"
hidden
onkeydown="handleMenuKeydown(event)"
>
<li role="menuitem" tabindex="-1">
<button type="button" onclick="newDocument()">
New <kbd aria-hidden="true">Ctrl+N</kbd>
</button>
</li>

<li role="menuitem" tabindex="-1">
<button type="button" onclick="openDocument()">
Open <kbd aria-hidden="true">Ctrl+O</kbd>
</button>
</li>

<li role="separator" aria-hidden="true"></li>

<li
role="menuitem"
aria-haspopup="true"
aria-expanded="false"
tabindex="-1"
>
<button type="button" onclick="toggleRecentMenu()">
Recent Files <span aria-hidden="true">โ–ถ</span>
</button>

<!-- Submenu -->
<ul role="menu" hidden>
<li role="menuitem" tabindex="-1">
<button type="button">Document1.txt</button>
</li>
<li role="menuitem" tabindex="-1">
<button type="button">Document2.txt</button>
</li>
</ul>
</li>
</ul>
</div>

<script>
function handleMenuTriggerKeydown(event) {
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
event.preventDefault();
openMenu();
break;

case 'ArrowUp':
event.preventDefault();
openMenu(true); // Focus last item
break;
}
}

function handleMenuKeydown(event) {
const menuItems = [
...event.target
.closest('[role="menu"]')
.querySelectorAll('[role="menuitem"]:not([aria-hidden="true"])'),
];
const currentIndex = menuItems.indexOf(
event.target.closest('[role="menuitem"]')
);

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
const nextIndex = (currentIndex + 1) % menuItems.length;
menuItems[nextIndex].querySelector('button').focus();
break;

case 'ArrowUp':
event.preventDefault();
const prevIndex =
currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1;
menuItems[prevIndex].querySelector('button').focus();
break;

case 'Enter':
case ' ':
event.preventDefault();
event.target.click();
break;

case 'Escape':
closeMenu();
document.getElementById('menu-trigger').focus();
break;

case 'ArrowRight':
// Handle submenu navigation
const submenu = event.target
.closest('[role="menuitem"]')
.querySelector('[role="menu"]');
if (submenu) {
event.preventDefault();
openSubmenu(submenu);
}
break;

case 'ArrowLeft':
// Close submenu or return to parent menu
const parentMenu = event.target
.closest('[role="menu"]')
.parentElement.closest('[role="menu"]');
if (parentMenu) {
event.preventDefault();
closeSubmenu();
// Focus parent menu item
}
break;
}
}
</script>

Tab Navigationโ€‹

<div class="tab-container">
<div
role="tablist"
aria-label="Settings sections"
onkeydown="handleTabListKeydown(event)"
>
<button
role="tab"
id="general-tab"
aria-selected="true"
aria-controls="general-panel"
tabindex="0"
>
General
</button>

<button
role="tab"
id="privacy-tab"
aria-selected="false"
aria-controls="privacy-panel"
tabindex="-1"
>
Privacy
</button>

<button
role="tab"
id="security-tab"
aria-selected="false"
aria-controls="security-panel"
tabindex="-1"
>
Security
</button>
</div>

<div
role="tabpanel"
id="general-panel"
aria-labelledby="general-tab"
tabindex="0"
>
<h3>General Settings</h3>
<label> <input type="checkbox" /> Enable notifications </label>
</div>

<div
role="tabpanel"
id="privacy-panel"
aria-labelledby="privacy-tab"
tabindex="0"
hidden
>
<h3>Privacy Settings</h3>
<label> <input type="checkbox" /> Share usage data </label>
</div>
</div>

<script>
function handleTabListKeydown(event) {
const tabs = [
...event.target
.closest('[role="tablist"]')
.querySelectorAll('[role="tab"]'),
];
const currentIndex = tabs.indexOf(event.target);
let nextIndex;

switch (event.key) {
case 'ArrowRight':
case 'ArrowLeft':
event.preventDefault();

if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length;
} else {
nextIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
}

selectTab(tabs[nextIndex]);
break;

case 'Home':
event.preventDefault();
selectTab(tabs[0]);
break;

case 'End':
event.preventDefault();
selectTab(tabs[tabs.length - 1]);
break;
}
}

function selectTab(tab) {
// Update ARIA states
const tablist = tab.closest('[role="tablist"]');
const tabs = [...tablist.querySelectorAll('[role="tab"]')];

tabs.forEach(t => {
t.setAttribute('aria-selected', 'false');
t.tabIndex = -1;
});

tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
tab.focus();

// Show corresponding panel
const panels = [...document.querySelectorAll('[role="tabpanel"]')];
panels.forEach(p => (p.hidden = true));

const targetPanel = document.getElementById(
tab.getAttribute('aria-controls')
);
if (targetPanel) {
targetPanel.hidden = false;
}
}
</script>

Listbox/Combobox Navigationโ€‹

<div class="combobox-container">
<label for="country-input">Choose a country</label>

<div
role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-owns="country-listbox"
>
<input
type="text"
id="country-input"
aria-autocomplete="list"
aria-controls="country-listbox"
onkeydown="handleComboboxKeydown(event)"
oninput="filterOptions(event)"
onfocus="showOptions()"
onblur="hideOptions()"
/>
</div>

<ul
id="country-listbox"
role="listbox"
aria-label="Country options"
hidden
onkeydown="handleListboxKeydown(event)"
>
<li role="option" id="option-us" aria-selected="false">United States</li>
<li role="option" id="option-ca" aria-selected="false">Canada</li>
<li role="option" id="option-uk" aria-selected="false">United Kingdom</li>
</ul>
</div>

<script>
function handleComboboxKeydown(event) {
const listbox = document.getElementById('country-listbox');
const options = [
...listbox.querySelectorAll('[role="option"]:not([hidden])'),
];

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (listbox.hidden) {
showOptions();
}
focusOption(options[0]);
break;

case 'ArrowUp':
event.preventDefault();
if (listbox.hidden) {
showOptions();
}
focusOption(options[options.length - 1]);
break;

case 'Escape':
hideOptions();
break;

case 'Enter':
if (!listbox.hidden) {
const selectedOption = listbox.querySelector(
'[aria-selected="true"]'
);
if (selectedOption) {
selectOption(selectedOption);
}
}
break;
}
}

function handleListboxKeydown(event) {
const options = [
...event.target
.closest('[role="listbox"]')
.querySelectorAll('[role="option"]:not([hidden])'),
];
const currentIndex = options.indexOf(event.target);

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
const nextIndex = (currentIndex + 1) % options.length;
focusOption(options[nextIndex]);
break;

case 'ArrowUp':
event.preventDefault();
const prevIndex =
currentIndex === 0 ? options.length - 1 : currentIndex - 1;
focusOption(options[prevIndex]);
break;

case 'Enter':
case ' ':
event.preventDefault();
selectOption(event.target);
break;

case 'Escape':
hideOptions();
document.getElementById('country-input').focus();
break;

case 'Home':
event.preventDefault();
focusOption(options[0]);
break;

case 'End':
event.preventDefault();
focusOption(options[options.length - 1]);
break;

// Type-ahead support
default:
if (event.key.length === 1) {
const char = event.key.toLowerCase();
const matchingOption = options.find(option =>
option.textContent.toLowerCase().startsWith(char)
);
if (matchingOption) {
focusOption(matchingOption);
}
}
break;
}
}

function focusOption(option) {
const options = [
...option.parentElement.querySelectorAll('[role="option"]'),
];
options.forEach(opt => opt.setAttribute('aria-selected', 'false'));

option.setAttribute('aria-selected', 'true');
option.focus();

// Update input value for preview
const input = document.getElementById('country-input');
input.value = option.textContent;
}
</script>

Live Regions & Dynamic Contentโ€‹

Managing dynamic content updates for screen reader users.

Live Region Typesโ€‹

<!-- Polite announcements (don't interrupt) -->
<div id="status-updates" aria-live="polite" aria-atomic="false" class="sr-only">
<!-- Status messages appear here -->
</div>

<!-- Assertive announcements (interrupt current speech) -->
<div
id="error-announcements"
aria-live="assertive"
aria-atomic="true"
role="alert"
class="sr-only"
>
<!-- Critical errors appear here -->
</div>

<!-- Off - no announcements -->
<div id="debug-info" aria-live="off" aria-relevant="text additions removals">
<!-- Debug info that shouldn't be announced -->
</div>

<!-- Log for sequential updates -->
<div
id="activity-log"
role="log"
aria-label="Recent activity"
aria-live="polite"
>
<ul>
<li>User John logged in at 2:30 PM</li>
<li>Document saved at 2:32 PM</li>
<!-- New items added here -->
</ul>
</div>

<!-- Timer/countdown -->
<div id="timer-display" role="timer" aria-live="polite" aria-atomic="true">
<span class="time">05:00</span>
<span class="label">minutes remaining</span>
</div>

Dynamic Content Managementโ€‹

class LiveRegionManager {
constructor() {
this.regions = {
status: this.createRegion('polite', false),
alert: this.createRegion('assertive', true),
log: this.createRegion('polite', false, 'log'),
};
}

createRegion(level, atomic, role = null) {
const region = document.createElement('div');
region.setAttribute('aria-live', level);
region.setAttribute('aria-atomic', atomic.toString());
region.className = 'sr-only';

if (role) {
region.setAttribute('role', role);
}

document.body.appendChild(region);
return region;
}

announce(message, type = 'status', delay = 100) {
// Small delay ensures screen readers catch the update
setTimeout(() => {
const region = this.regions[type];
if (region) {
region.textContent = message;

// Clear after announcement to allow repeated messages
setTimeout(() => {
region.textContent = '';
}, 1000);
}
}, delay);
}

appendToLog(message, timestamp = true) {
const logRegion = this.regions.log;
const entry = document.createElement('div');

if (timestamp) {
const time = new Date().toLocaleTimeString();
entry.textContent = `${time}: ${message}`;
} else {
entry.textContent = message;
}

logRegion.appendChild(entry);

// Limit log entries to prevent performance issues
const entries = logRegion.children;
if (entries.length > 50) {
logRegion.removeChild(entries[0]);
}
}
}

// Usage examples
const liveRegions = new LiveRegionManager();

// Status updates
function saveDocument() {
// ... save logic
liveRegions.announce('Document saved successfully');
}

// Error alerts
function handleError(error) {
liveRegions.announce(`Error: ${error.message}`, 'alert');
}

// Activity logging
function logActivity(activity) {
liveRegions.appendToLog(activity);
}

Form Validation with Live Feedbackโ€‹

<form novalidate onsubmit="handleFormSubmit(event)">
<div class="form-group">
<label for="username">Username *</label>
<input
type="text"
id="username"
name="username"
required
minlength="3"
aria-describedby="username-help username-feedback"
oninput="validateField(this)"
onblur="validateField(this, true)"
/>

<div id="username-help" class="help-text">
Username must be at least 3 characters long
</div>

<div
id="username-feedback"
aria-live="polite"
aria-atomic="true"
class="validation-feedback"
>
<!-- Validation messages appear here -->
</div>
</div>

<div class="form-group">
<label for="email">Email Address *</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-help email-feedback"
oninput="validateField(this)"
onblur="validateField(this, true)"
/>

<div id="email-help" class="help-text">Enter a valid email address</div>

<div
id="email-feedback"
aria-live="polite"
class="validation-feedback"
></div>
</div>

<!-- Form-level feedback -->
<div
id="form-feedback"
role="alert"
aria-live="assertive"
class="form-errors"
>
<!-- Form submission errors appear here -->
</div>

<button type="submit">Submit</button>
</form>

<script>
function validateField(field, showSuccess = false) {
const feedback = document.getElementById(field.id + '-feedback');
const isValid = field.checkValidity();

// Clear previous state
field.removeAttribute('aria-invalid');
feedback.textContent = '';
feedback.className = 'validation-feedback';

if (!isValid) {
field.setAttribute('aria-invalid', 'true');
feedback.className = 'validation-feedback error';
feedback.textContent = field.validationMessage;
} else if (showSuccess && field.value.trim()) {
feedback.className = 'validation-feedback success';
feedback.textContent = 'Valid';
}
}

function handleFormSubmit(event) {
event.preventDefault();

const form = event.target;
const formFeedback = document.getElementById('form-feedback');
const isValid = form.checkValidity();

if (!isValid) {
// Show form-level errors
formFeedback.innerHTML = `
<h3>Please correct the following errors:</h3>
<ul>
${Array.from(form.elements)
.filter(el => !el.checkValidity() && el.name)
.map(
el =>
`<li>${el.labels[0]?.textContent || el.name}: ${el.validationMessage}</li>`
)
.join('')}
</ul>
`;

// Focus first invalid field
const firstInvalid = form.querySelector(':invalid');
if (firstInvalid) {
firstInvalid.focus();
}
} else {
formFeedback.innerHTML = '<p>Form submitted successfully!</p>';
// Process form...
}
}
</script>

Form Accessibilityโ€‹

Comprehensive form accessibility patterns and techniques.

Field Grouping and Relationshipsโ€‹

<form>
<!-- Required field indicators -->
<fieldset>
<legend>
Personal Information
<span class="required-note">(* indicates required fields)</span>
</legend>

<div class="form-row">
<div class="form-group">
<label for="first-name">
First Name *
<span class="sr-only">(required)</span>
</label>
<input
type="text"
id="first-name"
name="firstName"
required
autocomplete="given-name"
aria-describedby="name-help"
/>
</div>

<div class="form-group">
<label for="last-name">
Last Name *
<span class="sr-only">(required)</span>
</label>
<input
type="text"
id="last-name"
name="lastName"
required
autocomplete="family-name"
/>
</div>
</div>

<div id="name-help" class="help-text">
Enter your full legal name as it appears on official documents
</div>
</fieldset>

<!-- Radio button groups -->
<fieldset>
<legend>Contact Preference</legend>
<div role="radiogroup" aria-describedby="contact-help">
<label>
<input type="radio" name="contact" value="email" checked />
Email
</label>
<label>
<input type="radio" name="contact" value="phone" />
Phone
</label>
<label>
<input type="radio" name="contact" value="mail" />
Mail
</label>
</div>
<div id="contact-help" class="help-text">
Choose how you'd like us to contact you
</div>
</fieldset>

<!-- Checkbox groups -->
<fieldset>
<legend>Interests (select all that apply)</legend>
<div class="checkbox-group">
<label>
<input type="checkbox" name="interests" value="technology" />
Technology
</label>
<label>
<input type="checkbox" name="interests" value="design" />
Design
</label>
<label>
<input type="checkbox" name="interests" value="business" />
Business
</label>
</div>
</fieldset>

<!-- Complex input with multiple parts -->
<fieldset>
<legend>Phone Number</legend>
<div class="phone-input" role="group" aria-labelledby="phone-legend">
<label for="phone-country" class="sr-only">Country code</label>
<select
id="phone-country"
name="phoneCountry"
aria-describedby="phone-help"
>
<option value="+1">+1 (US)</option>
<option value="+44">+44 (UK)</option>
<option value="+33">+33 (FR)</option>
</select>

<label for="phone-number" class="sr-only">Phone number</label>
<input
type="tel"
id="phone-number"
name="phoneNumber"
placeholder="(555) 123-4567"
autocomplete="tel"
aria-describedby="phone-help"
/>
</div>
<div id="phone-help" class="help-text">
Include area code for US numbers
</div>
</fieldset>
</form>

Advanced Input Typesโ€‹

<div class="advanced-inputs">
<!-- Date picker -->
<div class="form-group">
<label for="birth-date">Date of Birth</label>
<input
type="date"
id="birth-date"
name="birthDate"
min="1900-01-01"
max="2023-12-31"
aria-describedby="date-help"
onchange="validateAge(this)"
/>
<div id="date-help" class="help-text">Must be 18 years or older</div>
</div>

<!-- Range slider -->
<div class="form-group">
<label for="salary-range">Expected Salary Range</label>
<div class="range-container">
<input
type="range"
id="salary-range"
name="salaryRange"
min="30000"
max="200000"
step="5000"
value="75000"
aria-describedby="salary-help salary-value"
oninput="updateRangeValue(this)"
/>
<output id="salary-value" for="salary-range" aria-live="polite">
$75,000
</output>
</div>
<div id="salary-help" class="help-text">
Adjust slider to set your expected salary range
</div>
</div>

<!-- File upload -->
<div class="form-group">
<label for="resume-upload">Upload Resume</label>
<input
type="file"
id="resume-upload"
name="resume"
accept=".pdf,.doc,.docx"
aria-describedby="file-help file-status"
onchange="handleFileUpload(this)"
/>
<div id="file-help" class="help-text">
Accepted formats: PDF, DOC, DOCX (max 5MB)
</div>
<div id="file-status" aria-live="polite" class="file-status">
<!-- Upload status appears here -->
</div>
</div>

<!-- Multi-step progress -->
<div class="progress-container">
<div
role="progressbar"
aria-valuenow="2"
aria-valuemin="1"
aria-valuemax="4"
aria-labelledby="progress-label"
>
<div id="progress-label">Step 2 of 4: Contact Information</div>
<div class="progress-bar">
<div class="progress-fill" style="width: 50%"></div>
</div>
</div>
</div>
</div>

<script>
function updateRangeValue(slider) {
const output = document.getElementById('salary-value');
const value = parseInt(slider.value);
output.textContent = `${value.toLocaleString()}`;
}

function handleFileUpload(input) {
const status = document.getElementById('file-status');
const file = input.files[0];

if (file) {
if (file.size > 5 * 1024 * 1024) {
// 5MB
status.innerHTML =
'<span class="error">File too large. Maximum size is 5MB.</span>';
input.value = '';
} else {
status.innerHTML = `<span class="success">File selected: ${file.name}</span>`;
}
} else {
status.textContent = '';
}
}
</script>

Accessible modal and dialog implementations with proper focus management.

Basic Modal Dialogโ€‹

<div
id="modal-overlay"
class="modal-overlay"
hidden
onclick="handleOverlayClick(event)"
>
<div
id="confirmation-modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
class="modal"
onkeydown="handleModalKeydown(event)"
>
<div class="modal-header">
<h2 id="modal-title">Confirm Deletion</h2>
<button
type="button"
class="close-button"
aria-label="Close dialog"
onclick="closeModal()"
>
<span aria-hidden="true">ร—</span>
</button>
</div>

<div class="modal-body">
<p id="modal-description">
Are you sure you want to delete this item? This action cannot be undone.
</p>
</div>

<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal()">
Cancel
</button>
<button
type="button"
class="btn btn-danger"
onclick="confirmDelete()"
autofocus
>
Delete
</button>
</div>
</div>
</div>

<script>
let previousActiveElement = null;
const focusManager = new FocusManager();

function openModal(modalId) {
// Store current focus
previousActiveElement = document.activeElement;

const overlay = document.getElementById('modal-overlay');
const modal = document.getElementById(modalId);

// Show modal
overlay.hidden = false;

// Trap focus within modal
focusManager.trapFocus(modal);

// Focus first focusable element or autofocus element
const autofocusElement = modal.querySelector('[autofocus]');
const firstFocusable = focusManager.getFocusableElements(modal)[0];

if (autofocusElement) {
autofocusElement.focus();
} else if (firstFocusable) {
firstFocusable.focus();
}

// Prevent body scroll
document.body.style.overflow = 'hidden';

// Announce to screen readers
setTimeout(() => {
liveRegions.announce('Dialog opened', 'status');
}, 100);
}

function closeModal() {
const overlay = document.getElementById('modal-overlay');

// Hide modal
overlay.hidden = true;

// Restore focus
if (previousActiveElement) {
previousActiveElement.focus();
previousActiveElement = null;
}

// Restore body scroll
document.body.style.overflow = '';

// Announce to screen readers
liveRegions.announce('Dialog closed', 'status');
}

function handleModalKeydown(event) {
if (event.key === 'Escape') {
closeModal();
}
}

function handleOverlayClick(event) {
if (event.target === event.currentTarget) {
closeModal();
}
}

function confirmDelete() {
// Perform deletion
liveRegions.announce('Item deleted successfully', 'status');
closeModal();
}
</script>

Alert Dialogโ€‹

<div
id="alert-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="alert-title"
aria-describedby="alert-description"
class="modal alert-modal"
hidden
>
<div class="modal-content">
<div class="alert-icon" aria-hidden="true">โš ๏ธ</div>
<h2 id="alert-title">System Error</h2>
<p id="alert-description">
An unexpected error occurred. Your work has been saved automatically.
</p>
<button
type="button"
class="btn btn-primary"
onclick="closeAlertModal()"
autofocus
>
OK
</button>
</div>
</div>

Form Dialogโ€‹

<div
id="form-modal"
role="dialog"
aria-modal="true"
aria-labelledby="form-modal-title"
class="modal form-modal"
hidden
>
<form onsubmit="handleFormModalSubmit(event)" novalidate>
<div class="modal-header">
<h2 id="form-modal-title">Add New Contact</h2>
<button type="button" aria-label="Close" onclick="closeFormModal()">
ร—
</button>
</div>

<div class="modal-body">
<div class="form-group">
<label for="contact-name">Name *</label>
<input
type="text"
id="contact-name"
name="name"
required
aria-describedby="name-error"
autofocus
/>
<div
id="name-error"
role="alert"
aria-live="assertive"
class="error-message"
></div>
</div>

<div class="form-group">
<label for="contact-email">Email</label>
<input
type="email"
id="contact-email"
name="email"
aria-describedby="email-error"
/>
<div
id="email-error"
role="alert"
aria-live="assertive"
class="error-message"
></div>
</div>
</div>

<div class="modal-footer">
<button type="button" onclick="closeFormModal()">Cancel</button>
<button type="submit">Add Contact</button>
</div>
</form>
</div>

Testing & Validationโ€‹

Tools and techniques for testing accessibility implementation.

Automated Testingโ€‹

// Basic accessibility testing utilities
class AccessibilityTester {
constructor() {
this.violations = [];
}

testFocusManagement() {
const focusableElements = document.querySelectorAll(
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);

focusableElements.forEach(element => {
if (element.tabIndex === 0 && !this.isVisible(element)) {
this.violations.push({
type: 'focus',
element: element,
message: 'Focusable element is not visible',
});
}
});
}

testLabels() {
const inputs = document.querySelectorAll('input, select, textarea');

inputs.forEach(input => {
const hasLabel = this.hasLabel(input);
if (!hasLabel) {
this.violations.push({
type: 'label',
element: input,
message: 'Form control missing label',
});
}
});
}

testHeadingStructure() {
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
let previousLevel = 0;

headings.forEach((heading, index) => {
const level = parseInt(heading.tagName.charAt(1));

if (index === 0 && level !== 1) {
this.violations.push({
type: 'heading',
element: heading,
message: 'Page should start with h1',
});
}

if (level > previousLevel + 1) {
this.violations.push({
type: 'heading',
element: heading,
message: `Heading level jumps from h${previousLevel} to h${level}`,
});
}

previousLevel = level;
});
}

testAriaLabels() {
const elementsWithAriaLabel = document.querySelectorAll('[aria-label]');
const elementsWithAriaLabelledby =
document.querySelectorAll('[aria-labelledby]');

elementsWithAriaLabelledby.forEach(element => {
const ids = element.getAttribute('aria-labelledby').split(' ');
ids.forEach(id => {
if (!document.getElementById(id)) {
this.violations.push({
type: 'aria',
element: element,
message: `aria-labelledby references non-existent id: ${id}`,
});
}
});
});
}

hasLabel(input) {
// Check for label element
if (input.labels && input.labels.length > 0) return true;

// Check for aria-label
if (input.getAttribute('aria-label')) return true;

// Check for aria-labelledby
if (input.getAttribute('aria-labelledby')) {
const ids = input.getAttribute('aria-labelledby').split(' ');
return ids.every(id => document.getElementById(id));
}

return false;
}

isVisible(element) {
const style = window.getComputedStyle(element);
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0'
);
}

runAllTests() {
this.violations = [];
this.testFocusManagement();
this.testLabels();
this.testHeadingStructure();
this.testAriaLabels();

return this.violations;
}

generateReport() {
const violations = this.runAllTests();

console.group('Accessibility Test Results');

if (violations.length === 0) {
console.log('โœ… No violations found');
} else {
console.log(`โŒ Found ${violations.length} violations:`);

violations.forEach((violation, index) => {
console.group(`${index + 1}. ${violation.type.toUpperCase()}`);
console.log('Message:', violation.message);
console.log('Element:', violation.element);
console.groupEnd();
});
}

console.groupEnd();

return violations;
}
}

// Usage
const tester = new AccessibilityTester();
tester.generateReport();

Manual Testing Checklistโ€‹

## Accessibility Testing Checklist

### Keyboard Navigation

- [ ] All interactive elements are reachable via Tab key
- [ ] Tab order is logical and matches visual layout
- [ ] Focus indicators are visible and clear
- [ ] Escape key closes modals/menus appropriately
- [ ] Arrow keys work in menus, tabs, and other widgets
- [ ] Enter/Space activate buttons and links

### Screen Reader Testing

- [ ] Page has proper heading structure (h1, h2, h3, etc.)
- [ ] All images have appropriate alt text
- [ ] Form controls have labels
- [ ] Error messages are announced
- [ ] Dynamic content updates are announced
- [ ] Landmarks help with navigation

### Visual Testing

- [ ] Text has sufficient color contrast (4.5:1 for normal, 3:1 for large)
- [ ] Focus indicators are visible
- [ ] Text is readable when zoomed to 200%
- [ ] No information conveyed by color alone
- [ ] Content reflows properly on mobile devices

### ARIA Testing

- [ ] ARIA roles are used appropriately
- [ ] ARIA states update correctly (expanded, selected, etc.)
- [ ] aria-live regions announce changes
- [ ] No invalid ARIA attribute combinations

### Form Testing

- [ ] Required fields are clearly marked
- [ ] Validation errors are associated with fields
- [ ] Error messages are descriptive
- [ ] Success messages are announced
- [ ] Field groups use fieldset/legend appropriately

Testing Tools Integrationโ€‹

// Integration with popular testing tools
class AccessibilityTestSuite {
async runAxeTests() {
// Requires axe-core library
if (typeof axe !== 'undefined') {
try {
const results = await axe.run();
console.log('Axe test results:', results);
return results.violations;
} catch (error) {
console.error('Axe testing failed:', error);
return [];
}
} else {
console.warn('axe-core library not loaded');
return [];
}
}

simulateScreenReader() {
// Basic screen reader simulation
const elements = document.querySelectorAll('*');
const announcement = [];

elements.forEach(element => {
if (this.isVisible(element) && this.hasTextContent(element)) {
const role =
element.getAttribute('role') || this.getImplicitRole(element);
const label = this.getAccessibleName(element);

if (label) {
announcement.push({
element: element,
role: role,
name: label,
states: this.getStates(element),
});
}
}
});

return announcement;
}

getImplicitRole(element) {
const roleMap = {
button: 'button',
a: element.href ? 'link' : null,
input: this.getInputRole(element),
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
h5: 'heading',
h6: 'heading',
nav: 'navigation',
main: 'main',
header: 'banner',
footer: 'contentinfo',
aside: 'complementary',
};

return roleMap[element.tagName.toLowerCase()] || null;
}

getInputRole(input) {
const typeRoleMap = {
checkbox: 'checkbox',
radio: 'radio',
range: 'slider',
text: 'textbox',
email: 'textbox',
password: 'textbox',
search: 'searchbox',
};

return typeRoleMap[input.type] || 'textbox';
}

getAccessibleName(element) {
// Priority order for accessible name calculation

// 1. aria-label
if (element.hasAttribute('aria-label')) {
return element.getAttribute('aria-label');
}

// 2. aria-labelledby
if (element.hasAttribute('aria-labelledby')) {
const ids = element.getAttribute('aria-labelledby').split(' ');
const names = ids
.map(id => {
const referencedElement = document.getElementById(id);
return referencedElement ? referencedElement.textContent.trim() : '';
})
.filter(name => name);

if (names.length > 0) {
return names.join(' ');
}
}

// 3. Associated label
if (element.labels && element.labels.length > 0) {
return element.labels[0].textContent.trim();
}

// 4. alt attribute (for images)
if (element.hasAttribute('alt')) {
return element.getAttribute('alt');
}

// 5. title attribute
if (element.hasAttribute('title')) {
return element.getAttribute('title');
}

// 6. Text content (for certain elements)
if (
['button', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(
element.tagName.toLowerCase()
)
) {
return element.textContent.trim();
}

return null;
}

getStates(element) {
const states = [];

// Common ARIA states
const ariaStates = [
'aria-expanded',
'aria-checked',
'aria-selected',
'aria-pressed',
'aria-disabled',
'aria-invalid',
'aria-hidden',
];

ariaStates.forEach(state => {
if (element.hasAttribute(state)) {
const value = element.getAttribute(state);
states.push(`${state}: ${value}`);
}
});

// HTML states
if (element.disabled) states.push('disabled');
if (element.required) states.push('required');
if (element.checked) states.push('checked');

return states;
}

isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);

return (
rect.width > 0 &&
rect.height > 0 &&
style.opacity !== '0' &&
style.visibility !== 'hidden' &&
style.display !== 'none'
);
}

hasTextContent(element) {
return element.textContent && element.textContent.trim().length > 0;
}
}

// Usage
const testSuite = new AccessibilityTestSuite();

// Run manual tests
const manualResults = new AccessibilityTester().runAllTests();

// Run axe tests (if available)
testSuite.runAxeTests().then(axeResults => {
console.log('Combined test results:', {
manual: manualResults,
axe: axeResults,
});
});

// Simulate screen reader output
const srOutput = testSuite.simulateScreenReader();
console.log('Screen reader simulation:', srOutput);

Summaryโ€‹

This comprehensive guide covers:

  • ARIA Roles: Semantic meaning for assistive technologies
  • ARIA States & Properties: Dynamic states and relationships
  • Keyboard Navigation: Proper focus management and interaction patterns
  • Live Regions: Dynamic content announcements
  • Form Accessibility: Proper labeling, validation, and error handling
  • Modal Patterns: Focus trapping and proper dialog implementation
  • Testing: Automated and manual accessibility validation

Key Principles to Rememberโ€‹

  1. Semantic HTML First: Use native elements when possible before adding ARIA
  2. Progressive Enhancement: Build accessible foundations, then enhance
  3. Test with Real Users: Nothing replaces testing with actual assistive technology users
  4. Focus Management: Always know where focus is and where it should go
  5. Clear Communication: Provide clear, descriptive labels and instructions

Quick Referenceโ€‹

Essential ARIA attributes:

  • aria-label, aria-labelledby, aria-describedby for labeling
  • aria-expanded, aria-selected, aria-checked for states
  • aria-live, role="alert" for dynamic content
  • aria-hidden to hide decorative elements

Key keyboard patterns:

  • Tab/Shift+Tab for focus navigation
  • Enter/Space for activation
  • Arrow keys for widget navigation
  • Escape for dismissal/cancellation

Testing priorities:

  1. Keyboard-only navigation
  2. Screen reader compatibility
  3. Color contrast and visual clarity
  4. Error handling and feedback
  5. Mobile accessibility